2026-09-24 · class-notes, linux, bash, shell, expansion, quoting, globbing, command-line
Full class notes for TLCL chapter 7: pathname, tilde, arithmetic, parameter and command expansion, then double quotes, single quotes and backslash escaping.
Class notes · Shotts, The Linux Command Line (2nd ed.) · Chapter 7
Quick reference: the short version of this chapter is the Linux chapter 7 sheet. It follows chapter 6 Redirection and explains something that has been happening invisibly since chapter 4: when you type
rm *.txt,rmnever sees the*.
By the end of these notes you should be able to:
echo before running a destructive command.Press Enter and the shell does not hand your text straight to the program. It reads the line, performs expansion — replacing certain characters and patterns with something else — and only then runs the resulting command. Most of the time the difference is invisible, which is exactly why it catches people out.
+------------------------------------------------------------------------------+
| |
| You type: ls -l *.txt |
| | |
| v the shell expands |
| Shell builds: ls -l a.txt b.txt |
| | |
| v only now does the program start |
| ls receives: -l a.txt b.txt (it never sees the asterisk) |
| |
+------------------------------------------------------------------------------+
echo is the safety netecho prints its arguments after expansion, so putting echo in front of a command shows you precisely what the shell is about to do. Get into the habit before anything destructive:
echo rm *.log
Read the output, and if it is what you meant, run the command without echo.
Caution: the moment to check is before
rm, not after.rmreceives a list of filenames and has no idea a pattern was involved, so it cannot warn you that your pattern matched more than you thought.
Pathname expansion, also called globbing, is the one you already use. The shell replaces a pattern with the names of the files that match it — in alphabetical order, and only names that actually exist.
| Pattern | Matches |
|---|---|
* |
Any characters, including none |
? |
Exactly one character |
[abc] |
One character from the set |
[!abc] |
One character not in the set |
[[:digit:]] |
One character from a named class |
In a directory holding Makefile, a.txt, b.txt, c.log, data1, data2, data10 and .hidden, I ran these and got:
| Command | Result |
|---|---|
echo * |
Makefile a.txt b.txt c.log data1 data10 data2 |
echo *.txt |
a.txt b.txt |
echo [ab].txt |
a.txt b.txt |
echo data? |
data1 data2 — data10 has two characters after data |
Two things to take from that. First, * does not match hidden files: .hidden is absent from echo *, and you need a pattern that starts with a dot to reach it. Second, sorting is by character, not by number, which is why data10 lands between data1 and data2.
Caution: if a pattern matches nothing, bash leaves it alone by default — the command receives the literal
*.xyz. That is how you end up with a file named*.xyz, or a confusing error from a program that was handed an asterisk.
Note (beyond this lesson): the book also covers brace expansion in this chapter, which generates text rather than matching files:
echo {A,B}-{1,2}producesA-1 A-2 B-1 B-2, andecho {01..05}produces01 02 03 04 05. Unlike globbing, the results need not exist, which makes it useful for creating things:mkdir -p project/{src,docs,tests}.
A ~ at the start of a word expands to a home directory:
| Written | Expands to |
|---|---|
~ |
Your own home directory |
~username |
That user's home directory |
~nosuchuser |
Nothing — it stays as literal text |
Running as root, echo ~ and echo ~root both returned /root, while echo ~nosuchuser printed ~nosuchuser unchanged. That last behaviour is worth remembering: a typo in a username does not produce an error, it produces a path that looks like a filename.
$((expression)) performs integer arithmetic and substitutes the result:
echo $((2 + 2))
echo $(((5 ** 2) * 3))
| Operator | Meaning |
|---|---|
+ - * / |
Add, subtract, multiply, divide |
% |
Remainder (modulo) |
** |
Exponentiation |
The results I verified: 2+2 gives 4, 5/2 gives 2, 5%2 gives 1, 2**10 gives 1024, 10/3 gives 3, and (5**2)*3 gives 75.
Integers only. Division truncates towards zero — 7/2 is 3 and -7/2 is −3 — and there are no decimals at all. Dividing by zero is an error, not an infinity: the shell reported a division-by-zero error and produced no value.
Spaces inside the expression are ignored, so use them for readability. Expansions nest, too: echo $(( $(echo 3) + 4 )) returned 7.
Note (beyond this lesson): for anything non-integer, use
bc -l—echo "scale=2; 10/3" | bc— orawk. Reaching for$(( ))and being surprised by a truncated answer is a common source of wrong numbers in scripts.
$name is replaced with the value of the variable or environment variable of that name:
foo=bar
echo $foo
echo ${foo}s
The braces in ${foo} exist to mark where the name ends. Without them, echo $foos looks for a variable called foos, which does not exist. I confirmed ${foo}s prints bars.
The crucial behaviour is what happens to a name that does not exist: the expansion produces an empty string, silently. There is no error. echo $nosuch"|end" printed just |end. In a script, that is how rm -rf $DIR/ becomes rm -rf /.
Caution: a misspelled variable name is not an error, it is an empty string. In scripts,
set -umakes the shell treat unset variables as an error instead, which turns a silent disaster into a clean failure.
Common environment variables: $HOME, $USER, $PATH, $PWD, $SHELL. See all of them with printenv.
Command substitution, $(command), runs a command and substitutes its output into the line:
echo "kernel: $(uname -r)"
ls -l $(which cp)
Both worked as expected: the first printed the running kernel version, the second listed the cp binary by resolving its path first. The older backtick form, `command`, does the same thing and still appears in old scripts — echo \date +%Y`returned the year — but$( )` nests cleanly and is far easier to read, so prefer it.
Task: delete every .log file in a directory, safely.
echo *.log. The shell prints the actual list.*.log, nothing matched, and running rm would try to delete a file with that literal name.rm *.log, or rm -i *.log to confirm each one.ls over echo (ls -l *.log), since it also shows sizes and dates.Expansion is useful and occasionally disastrous. Quoting is how you control it.
Two things happen to an unquoted word that people forget: expansions run, and the result is split into separate arguments at whitespace (word splitting). Multiple spaces collapse, which is why echo this is a test printed a single space between each word, while the quoted version preserved all four spaces.
Word splitting is the reason filenames with spaces break things. With f="two words.txt", running ls -l $f produced two errors — the shell split the value into two and words.txt and handed ls two filenames that do not exist. Quoted as ls -l "$f", it listed the file correctly.
Inside double quotes, most special characters lose their meaning: whitespace is preserved, and pathname expansion and word splitting do not happen. Three expansions still run: parameter expansion, arithmetic expansion and command substitution.
I confirmed all three at once — echo "path $HOME arith $((2+2)) cmd $(echo hi)" printed path /root arith 4 cmd hi, double spaces intact.
That selectivity is the point. "$var" is how you pass a value as one argument no matter what it contains, which is why the rule is: always quote your variables.
+------------------------------------------------------------------------------+
| |
| Unquoted expansions run, globbing runs, words split on whitespace |
| "Double" $var $((math)) $(cmd) still run; globbing and splitting |
| do not; whitespace is preserved |
| 'Single' nothing expands at all; every character is literal |
| \c the next single character is literal |
| |
+------------------------------------------------------------------------------+
Double quotes also matter when capturing multi-line output. echo "$(cat f.txt)" preserved the newline between one and two; unquoted, echo $(cat f.txt) collapsed it to one two, because word splitting flattened the result.
Caution:
$keeps its meaning inside double quotes, which bites when writing money or literal text.echo "The total is $100.00"printedThe total is 00.00, because$1expanded to an empty string. Use\$or single quotes.
Single quotes suppress everything. No expansion of any kind happens; every character between them is literal. I ran a line containing $HOME, $((2+2)), $(echo hi) and * inside single quotes, and all four came out exactly as typed.
Use single quotes for anything the shell must not touch: regular expressions, awk and sed programs, passwords in a configuration line, and literal text containing $ or *.
The one thing single quotes cannot contain is a single quote. There is no escape inside them — a backslash is just a backslash, as I confirmed. To include an apostrophe, close the quote, add an escaped one, and reopen: 'it'\''s', or simply use double quotes instead.
| Use | Choose |
|---|---|
| A value that may contain spaces | Double quotes: "$file" |
Text with $ or * meant literally |
Single quotes |
A grep, sed or awk pattern |
Single quotes |
| A string that must include a variable | Double quotes |
A backslash makes the next single character literal. It is the tool for one awkward character in an otherwise normal line:
echo \*
echo "Its cost \$100"
mv two\ words.txt renamed.txt
The first printed a bare asterisk, the second printed the dollar sign as text. Inside double quotes, a backslash escapes $, `, " and \; against anything else it stays as a literal backslash — echo "hello\\there" printed hello\there.
A backslash at the end of a line escapes the newline itself, which is how long commands are wrapped:
grep "Failed password" auth.log | \
sort | uniq -c
Backslashes also introduce escape sequences that stand for characters you cannot easily type:
| Sequence | Meaning |
|---|---|
\n |
Newline |
\t |
Tab |
\\ |
A literal backslash |
\a |
Alert (the terminal bell) |
\b |
Backspace |
\r |
Carriage return |
\0nnn |
The character with octal value nnn |
Plain echo does not interpret these; you need echo -e, or $'...', or — best — printf. Both echo -e "a\tb\nc" and printf "a\tb\nc\n" produced a tab between a and b and a newline before c.
In the real world: prefer
printfoverecho -ein scripts.echo's handling of options and escapes varies between shells and systems;printfbehaves the same everywhere.
Task: find lines containing $100.00 in invoices.txt.
grep "$100.00" invoices.txt — fails silently. The shell expands $1 to nothing, so grep searches for 00.00.grep '$100.00' invoices.txt. The shell passes the pattern through untouched.$ and . mean something to grep too. In a regular expression, $ anchors to end of line and . matches any character. Escape them for a literal match: grep '\$100\.00' invoices.txt.grep -F '$100.00' invoices.txt treats the pattern as a fixed string.Two separate layers of interpretation — the shell's, then the program's — and the fix for each is different. Recognising which layer ate your characters is most of the skill.
Expansion happens before any program sees your command, so it is where a great many shell vulnerabilities live.
$var unquoted, the shell performs word splitting and globbing on its contents. Quote every expansion: "$var".$(...) and backticks run a command. A script that builds a command string from untrusted input can be made to execute anything the script's user can. Never interpolate untrusted data into a command line — pass it as a quoted argument instead.rm -rf $BACKUP_DIR/* with BACKUP_DIR unset becomes rm -rf /*. set -u (or set -euo pipefail at the top of a script) makes the shell stop instead.-rf, or one containing spaces or a newline, changes what a loop or a glob does. Use -- to end options (rm -- *) and prefer find -print0 | xargs -0 for bulk operations.* in a cron job or a cleanup script picks up whatever an attacker has dropped into that directory since you wrote it. Be specific, and be careful about running scripts from world-writable directories.ps) and in shell history. Pass secrets through environment variables read from a protected file, or via stdin — never as an argument.echo rm * costs one second and has saved a great many systems. In shared or production environments, treat it as procedure rather than habit.*, ~, $var or $(cmd).*, ?, [abc], [!abc], [[:class:]]) matches existing filenames only, sorts as text, ignores hidden files, and leaves a non-matching pattern unchanged.~ is your home, ~user is theirs, and an unknown username stays as literal text.$(( )) is integers only — division truncates and division by zero is an error.$var / ${var} substitutes a value, and an unset name silently becomes an empty string.$(cmd) substitutes a command's output; prefer it to backticks.$var, $(( )) and $( ); single quotes stop everything and cannot contain a single quote; backslash escapes one character, and sequences like \n and \t need printf or echo -e.echo first.| Term | Meaning |
|---|---|
| Expansion | The shell replacing text with something else before running a command |
| Pathname expansion | Replacing a pattern with matching filenames; also called globbing |
| Glob | A filename pattern using *, ? or [ ] |
| Character class | A named set such as [[:digit:]] or [[:alpha:]] |
| Tilde expansion | ~ becoming a home directory path |
| Arithmetic expansion | $((expr)) evaluating an integer expression |
| Parameter expansion | $name or ${name} becoming a variable's value |
| Command substitution | $(cmd) becoming a command's output |
| Backtick | The older ` form of command substitution |
| Word splitting | The shell breaking an unquoted result into separate arguments at whitespace |
| Double quotes | Suppress globbing and word splitting; allow $, $(( )) and $( ) |
| Single quotes | Suppress every expansion; everything inside is literal |
| Escape | A backslash making the next single character literal |
| Escape sequence | \n, \t and similar, standing for characters you cannot type |
| Environment variable | A named value the shell passes to the programs it runs |
set -u |
Shell option that makes an unset variable an error instead of an empty string |
echo * not list hidden files?data1, data2 and data10, what does data? match, and why?~jsmith expand to if no user jsmith exists?echo $((7 / 2)), and why?echo $foos print nothing when foo=bar, and how do you fix it?echo "The total is $100.00" print the wrong thing?echo "a\tb" and printf "a\tb\n"?ls -l $file fails with two "No such file" errors, but the file is definitely there. What is wrong?rm -rf $TMPDIR/* and has just wiped a server. Explain what happened and how to prevent it.grep to find the literal text $100.00. What do you type, and why?echo (or ls) in front of the command and read the expanded list before running the real one.data1 and data2 only. ? matches exactly one character, and data10 has two after data.~jsmith — no error, which makes the typo easy to miss.foos, which is unset. Use ${foo}s to mark where the name ends.set -u makes it an error instead.$1 is a parameter expansion and expands to nothing, leaving 00.00. Use \$ or single quotes.'it'\''s' or double quotes.echo prints a\tb literally unless given -e; printf interprets \t as a tab and behaves consistently across systems.ls two names. Use ls -l "$file".TMPDIR was unset, so $TMPDIR/* expanded to /*. Prevent it with set -u, and by checking the variable is non-empty before using it in a destructive command.grep -F '$100.00' file, or grep '\$100\.00' file. Single quotes stop the shell expanding $1, and -F (or escaping) stops grep treating $ and . as regular-expression characters.